iT邦幫忙

2023 iThome 鐵人賽

DAY 1
0
Modern Web

一些讓你看來很強的全端- trcp 伴讀系列 第 3

Day-03. 一些讓你看來很強的全端 TRPC 伴讀 - T3 Stack 介紹(上)

  • 分享至 

  • xImage
  •  

今天開始就由小弟本人帶大家使用 trpc 摟~,為了讓大家快速感受 trpc的魅力, 這邊會推薦別人整理好的 T3 stack 做介紹,T3 stack算是筆者認為把 trpc 架構用的非常好的框架,而目前 trpc 生態中主流會是以 T3 stack 為主,還沒用過的朋友可以去看看 T3 app~

但讀者也不用擔心看不懂,這邊筆者會一一介紹T3 stack 架構內容~

開始起專案吧

跟使用 vite 起專案一樣,只要打以下的 cli 指令就會幫你創建你需要的 template,讀者只要一一根據提示選擇需要內容就OK~

npm create t3-app@latest

T3 stack 很貼心的部分是有提供非常多的專案工具選項, nextAuthprismatailwindcss 以及非常重要的主題 trpc !!!,可以根據你的需求選擇你要的工具,這邊為了教學就全部都給他加進去XD,畢竟小孩才做選擇我全部都要哈哈。

簡單介紹一下選擇的套件~

nextAuth

介紹:他是一個做第三方登入的功能,搭配 next api route 實現 OAuth 2.0 驗證,保存第三方 user info 到你的 session 中。
網址: https://next-auth.js.org/

prisma

介紹:一個 type safe 的 ORM 框架,大部分有在用 trpc 的使用者都會去搭配使用。
網址: https://www.prisma.io/

react query

介紹:trpc 的 client 端是封包 react query 的內容,所以 call api 方式會跟 react query一樣。
網址: https://tanstack.com/query/v5/

trpc

介紹:第一天有介紹喔~。
網址: https://trpc.io/

那各位讀者也不用擔心沒用過,這些日後都會慢慢介紹的~

整體專案結構

建好專案後整體結構如下:

prisma : 整個 db 會用到的 schema都在這邊,同時所有 db migrate 紀錄都會在這邊出現,這邊得 migrate 資料呀,會根據你使用的 db 種類而有差異, prisma 整合非常多的 dbSQlnoSQl 都有例如 Postgresql 甚至是 mongodb 等等,那 t3 stack 預設會是使用 postgresql,所以這邊的 migrations 內容都是 postgresqlSQL 指令。

src/pages/api/auth/[...nextauth] : 這邊是 nextauth 做第三方登入需要回傳驗證結果的 api route,細節部分日後再一一解說 code

src/pages/api/auth/[trpc] : 這邊就是轉裡所有 trpc api 的入口, 還記得昨天提到 trpc 他是一個 clientserver 的設計模式吧,這邊就是 trpcserver 端入口,統一管理所有定義的 routereq contetglobal error handler 等等。

server/auth : 所有關於 nextauthoption 設定
server/db : 這邊就是你會用到的 db instance,因為t3 stack是用 prisma 這邊就是放 prisma 得 instance。

server/db : 這邊就是你會用到的 db instance,因為t3 stack是用 prisma 這邊就是放 prismainstance

utils/api : 所有 api 的呼叫都在這邊。

env.mjs : 你可以在這邊定義環境變數,t3 app 很貼心的是他是透過 zod 去幫你驗證你的 env 是否有缺漏、內容是否有誤,所有 env的引用都會在這邊。


├── README.md
├── docker-compose.yml
├── next-env.d.ts
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.cjs
├── prettier.config.cjs
├── prisma
│   ├── migrations
│   │   ├── 20230307161717_dev
│   │   │   └── migration.sql
│   │   ├── 20230307163349_
│   │   │   └── migration.sql
│   │   ├── 20230313151256_add_response_model
│   │   │   └── migration.sql
│   │   └── migration_lock.toml
│   └── schema.prisma
├── public
│   └── favicon.ico
├── src
│   ├── env.mjs
│   ├── pages
│   │   ├── _app.tsx
│   │   ├── api
│   │   │   ├── auth
│   │   │   │   └── [...nextauth].ts
│   │   │   └── trpc
│   │   │       └── [trpc].ts
│   │   ├── index.tsx
│   │   └── poll
│   │       └── [pollId].tsx
│   ├── server
│   │   ├── api
│   │   │   ├── root.ts
│   │   │   ├── routers
│   │   │   │   ├── example.ts
│   │   │   │   └── poll.ts
│   │   │   └── trpc.ts
│   │   ├── auth.ts
│   │   └── db.ts
│   ├── styles
│   │   └── globals.css
│   └── utils
│       └── api.ts
├── tailwind.config.cjs
└── tsconfig.json

最後補充 env 使用部分

相信大家看到這邊的 env 使用肯定會非常疑惑,放心我第一次看也是很不懂,這邊就一步一步帶大家使用。

step1 定義 schema

這邊可以根據你 env 的內容變化去制定 schema rule。

DATABASE_URL: z.string().url(): 必須是 https或是 http開頭的 envhttps://sample

NODE_ENV: z.enum(["development", "test", "production"]): 指定 NODE_ENV 有什麼環境選項。

NEXTAUTH_SECRET : 根據你的 NODE_ENV dynamic 你的 schema rule。

NEXTAUTH_URL : preprocess 他是一個可以幫你轉換變數成你要的 value,他有兩個參數,第一個是轉換的 callback function,第二個則是 schema rule,整個流程會是,假設你部署到 vercel 那你的 env 中就會自動有 vercel 提供的 predefinedenv VERCEL_URLVERCEL ,如果你是在 vercel 部署那就是替換 NEXTAUTH_URL 的 value 成 VERCEL_URL 的 value,反之則不變。

有 VERCEL_URL env
//.env
NEXTAUTH_URL="https://vercel.com/some_path"
NEXTAUTH_URL="http://localhost:3000"

const hasDeployToVercel = z.preprocess(
  (str) => process.env.VERCEL_URL ?? str,
  process.env.VERCEL ? z.string().min(1) : z.string().url(),
)

console.log(hasDeployToVercel.parse(process.env.NEXTAUTH_URL)) // "https://vercel.com/some_path"
沒有 VERCEL_URL env
//.env
NEXTAUTH_URL="http://localhost:3000"

const hasDeployToVercel = z.preprocess(
  (str) => process.env.VERCEL_URL ?? str,
  process.env.VERCEL ? z.string().min(1) : z.string().url(),
)

console.log(hasDeployToVercel.parse(process.env.NEXTAUTH_URL)) // "http://localhost:3000"

DISCORD_CLIENT_ID 跟 DISCORD_CLIENT_SECRET: string type。

const server = z.object({
  DATABASE_URL: z.string().url(), 
  NODE_ENV: z.enum(["development", "test", "production"]),
  NEXTAUTH_SECRET:
    process.env.NODE_ENV === "production"
      ? z.string().min(1)
      : z.string().min(1).optional(),
  NEXTAUTH_URL: z.preprocess(
    // This makes Vercel deployments not fail if you don't set NEXTAUTH_URL
    // Since NextAuth.js automatically uses the VERCEL_URL if present.
    (str) => process.env.VERCEL_URL ?? str,
    // VERCEL_URL doesn't include `https` so it cant be validated as a URL
    process.env.VERCEL ? z.string().min(1) : z.string().url(),
  ),
  // Add `.min(1) on ID and SECRET if you want to make sure they're not empty
  DISCORD_CLIENT_ID: z.string(),
  DISCORD_CLIENT_SECRET: z.string(),
});

step2 結合 schema

在 next 中你除了可以定義 server env 外,也可以定義 client 端內容,兩者可以合而為一統一驗證,同時別忘記引入你所有的 env 喔這邊用 processEnv 代替。

const server = z.object({
  DATABASE_URL: z.string().url(),
  NODE_ENV: z.enum(["development", "test", "production"]),
  NEXTAUTH_SECRET:
    process.env.NODE_ENV === "production"
      ? z.string().min(1)
      : z.string().min(1).optional(),
  NEXTAUTH_URL: z.preprocess(
    (str) => process.env.VERCEL_URL ?? str,
    process.env.VERCEL ? z.string().min(1) : z.string().url(),
  ),
  DISCORD_CLIENT_ID: z.string(),
  DISCORD_CLIENT_SECRET: z.string(),
});

const client = z.object({
  // NEXT_PUBLIC_CLIENTVAR: z.string().min(1),
});

const merged = server.merge(client); 


const processEnv = {
  DATABASE_URL: process.env.DATABASE_URL,
  NODE_ENV: process.env.NODE_ENV,
  NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
  NEXTAUTH_URL: process.env.NEXTAUTH_URL,
  DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID,
  DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET,
};

step3 驗證 error

所有 zod schema 的驗證都是呼叫前幾部定義好的 schema 並呼叫 parse function,但這邊採用 safeParse 原因是,safeParse 並不會 throw error出來,他只回傳一個 result,這個 result 包含 data 跟 status,相反 parse 如果驗證錯誤會直接 throw error 反之 return value,這邊會用 safeParse 是因為想客製化 error message 如果想改成 parse 也可以看大家的需求~

 const isServer = typeof window === "undefined";

  const parsed = /** @type {MergedSafeParseReturn} */ (
    isServer
      ? merged.safeParse(processEnv) // on server we can validate all env vars
      : client.safeParse(processEnv) // on client we can only validate the ones that are exposed
  );

  if (parsed.success === false) {
    console.error(
      "❌ Invalid environment variables:",
      parsed.error.flatten().fieldErrors,
    );
    throw new Error("Invalid environment variables");
  }

簡單 demo 看出 safeParse 跟 parse 差別


import { z } from "zod";
const schema = z.object({
  name: z.string()
})
let dataSuccess = {
  name: 'danny'
}
let dataWrong = {
  name: 10
}

const dataSuccessResult = schema.parse(dataSuccess) 
// { name: 'danny' }
const dataWrongResult = schema.parse(dataWrong)
// error - ZodError: [
//   {
//     "code": "invalid_type",
//     "expected": "string",
//     "received": "number",
//     "path": [
//       "name"
//     ],
//     "message": "Expected string, received number"
//   }
// ]

const dataSuccessResult1 = schema.safeParse(dataSuccess) 
// { success: true, data: { name: 'danny' } }
const dataWrongResult2 = schema.safeParse(dataWrong)
// {
//   success: false, 
//   error: [Getter],
//   _error: ZodError: [
//     {
//       "code": "invalid_type",
//       "expected": "string",
//       "received": "number",
//       "path": [
//         "name"
//       ],
//       "message": "Expected string, received number"
//     }
//   ]
// }

step4 撰寫 env jsdoc

jsDoc 並不是只會在 js 取寫,很多人可能以為 jsDoc 只是 typescript 出來前的替帶品,但其實兩者是可以一起使用的,好處就是 typescript 可以幫你做 type checkjsDoc 則是可以幫你的 code base 做詳細補充,但其實 jsDoc 也可以寫 type 喔~來看一下範例。

// 先定義 type ,用法就是 /** @typedef {your_type}  your_type_name*/


/** @typedef {z.infer<typeof merged>} MergedOutput */

// 指定變數 type ,用法 /**  @type {your_type_name}*/ , your_type_name 除了 typedef 定義的 name,以外也可以是 string 等原始 type 種類

let env = /** @type {MergedOutput} */(process.env)

這樣只要 hover env 變數就知道他有什麼 env 拉~

step5 客製化 error message

  1. 根據 SKIP_ENV_VALIDATION env 決定要不要做 env validate
  2. isServer 決定 safeParseschema 用哪個
  3. 判斷 parsed.success throw error
  4. proxy 方式檢查 env 引用,讓使用 env 錯誤時有 log 來源
if (!!process.env.SKIP_ENV_VALIDATION == false) {
  const isServer = typeof window === "undefined";

  const parsed = /** @type {MergedSafeParseReturn} */ (
    isServer
      ? merged.safeParse(processEnv) // on server we can validate all env vars
      : client.safeParse(processEnv) // on client we can only validate the ones that are exposed
  );

  if (parsed.success === false) {
    console.error(
      "❌ Invalid environment variables:",
      parsed.error.flatten().fieldErrors,
    );
    throw new Error("Invalid environment variables");
  }

  env = new Proxy(parsed.data, {
    get(target, prop) {
      if (typeof prop !== "string") return undefined;
      // Throw a descriptive error if a server-side env var is accessed on the client
      // Otherwise it would just be returning `undefined` and be annoying to debug
      if (!isServer && !prop.startsWith("NEXT_PUBLIC_"))
        throw new Error(
          process.env.NODE_ENV === "production"
            ? "❌ Attempted to access a server-side environment variable on the client"
            : `❌ Attempted to access server-side environment variable '${prop}' on the client`,
        );
      return target[/** @type {keyof typeof target} */ (prop)];
    },
  });
}

最後附上完整 code

// src/env.mjs
import { z } from "zod";

const server = z.object({
  DATABASE_URL: z.string().url(),
  NODE_ENV: z.enum(["development", "test", "production"]),
  NEXTAUTH_SECRET:
    process.env.NODE_ENV === "production"
      ? z.string().min(1)
      : z.string().min(1).optional(),
  NEXTAUTH_URL: z.preprocess(
    (str) => process.env.VERCEL_URL ?? str,
    process.env.VERCEL ? z.string().min(1) : z.string().url(),
  ),
  DISCORD_CLIENT_ID: z.string(),
  DISCORD_CLIENT_SECRET: z.string(),
});

const client = z.object({
  // NEXT_PUBLIC_CLIENTVAR: z.string().min(1),
});

const processEnv = {
  DATABASE_URL: process.env.DATABASE_URL,
  NODE_ENV: process.env.NODE_ENV,
  NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
  NEXTAUTH_URL: process.env.NEXTAUTH_URL,
  DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID,
  DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET,
};

const merged = server.merge(client);

/** @typedef {z.input<typeof merged>} MergedInput */
/** @typedef {z.infer<typeof merged>} MergedOutput */
/** @typedef {z.SafeParseReturnType<MergedInput, MergedOutput>} MergedSafeParseReturn */

let env = /** @type {MergedOutput} */ (process.env);

if (!!process.env.SKIP_ENV_VALIDATION == false) {
  const isServer = typeof window === "undefined";

  const parsed = /** @type {MergedSafeParseReturn} */ (
    isServer
      ? merged.safeParse(processEnv) // on server we can validate all env vars
      : client.safeParse(processEnv) // on client we can only validate the ones that are exposed
  );

  if (parsed.success === false) {
    console.error(
      "❌ Invalid environment variables:",
      parsed.error.flatten().fieldErrors,
    );
    throw new Error("Invalid environment variables");
  }

  env = new Proxy(parsed.data, {
    get(target, prop) {
      if (typeof prop !== "string") return undefined;
      // Throw a descriptive error if a server-side env var is accessed on the client
      // Otherwise it would just be returning `undefined` and be annoying to debug
      if (!isServer && !prop.startsWith("NEXT_PUBLIC_"))
        throw new Error(
          process.env.NODE_ENV === "production"
            ? "❌ Attempted to access a server-side environment variable on the client"
            : `❌ Attempted to access server-side environment variable '${prop}' on the client`,
        );
      return target[/** @type {keyof typeof target} */ (prop)];
    },
  });
}

export { env };

好了今天內容到這邊,明天會繼續陪大家研究其他資料夾部分,讀者如果有更多架構疑問可以下方留言一起討論喔~我們明天見

相關連結

https://trpc.io/
https://tanstack.com/query/v5/
https://www.prisma.io/
https://next-auth.js.org/

✅ 前端社群 :
https://lihi3.cc/kBe0Y


上一篇
Day-02. 一些讓你看來很強的全端 TRPC 伴讀 - 初探 RPC
下一篇
Day-04. 一些讓你看來很強的全端 TRPC 伴讀 - T3 Stack 介紹(下)
系列文
一些讓你看來很強的全端- trcp 伴讀30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言